Skip to content

Resolve hosts declared with multiple aliases in ssh config (fixes #12) - #13

Merged
ferraro merged 5 commits into
AiondaDotCom:mainfrom
badigit:fix/multi-alias-host-resolution
Aug 10, 2026
Merged

Resolve hosts declared with multiple aliases in ssh config (fixes #12)#13
ferraro merged 5 commits into
AiondaDotCom:mainfrom
badigit:fix/multi-alias-host-resolution

Conversation

@badigit

@badigit badigit commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Fixes #12.

Host docker-lxc hlab was unreachable under either alias — _assertKnownHostAlias rejected the request before ssh was ever spawned, even though native ssh resolves such aliases fine.

Cause

ssh-config@5 returns a plain string for a single-token value but an array of token objects ({val, separator, quoted}) once a multi-value directive carries more than one token. extractHostsFromConfig stored that array in alias verbatim, so every strict comparison downstream compared a string against an array and never matched.

Change

Normalize ssh-config values once, at parse time:

  • alias keeps holding the first alias, so the shape of listKnownHosts output is unchanged for existing single-alias configs
  • new aliases field carries the full list
  • matching goes through a hostMatchesAlias helper, replacing four hand-rolled === chains (_assertKnownHostAlias, getHostInfo, getPasswordForHost, getAllKnownHosts)

Two related defects fall out of the same normalization:

  • Host * !bastion is now skipped. The old section.value !== '*' check could not match a multi-token value, so such a block was emitted as a host if it carried a HostName.
  • Multi-token directives (ProxyCommand, SendEnv, …) are flattened to strings instead of being surfaced as arrays of token objects.

Tests

Four regression tests in server.test.mjs, all under SSHConfigParser > extractHostsFromConfig:

  • every alias of a multi-alias block is exposed
  • alias stays a string for single-alias hosts (guards the output shape)
  • a wildcard block carrying negations is skipped
  • multi-token directives are flattened

Verified they fail on the unpatched parser and pass with the fix.

Verification

Against a real 29-block ssh config: 40 aliases resolve, 0 raw arrays remain, host count in listKnownHosts output unchanged (29 → 29).

Note on the suite: on Windows 16 of the pre-existing tests fail on main before this change (POSIX chmod permission checks, the askpass shell script, and spawn/scp mocks). That set is byte-identical before and after this patch — no regressions introduced, but the suite is not green on Windows to begin with.

badigit and others added 5 commits July 18, 2026 13:42
`Host docker-lxc hlab` was unreachable under *either* alias.

ssh-config@5 returns a plain string for a single-token value but an array
of token objects ({val, separator, quoted}) once a multi-value directive
carries more than one token. extractHostsFromConfig stored that array in
`alias` verbatim, so every strict comparison downstream
(_assertKnownHostAlias, getHostInfo, getPasswordForHost, getAllKnownHosts)
compared a string against an array and never matched. The host was listed
by listKnownHosts but rejected by the whitelist gate before ssh was ever
spawned - even though native ssh resolves such aliases fine.

Normalize ssh-config values once at parse time: keep `alias` as the first
alias (output shape unchanged) and add `aliases` with the full list, then
match through a hostMatchesAlias helper.

Also fixed by the same normalization:
- `Host * !bastion` was not skipped, because a multi-token Host value is
  an array and never strictly equals '*'
- multi-token directives such as ProxyCommand were surfaced as arrays of
  token objects in listKnownHosts output instead of readable strings

Verified against a real 29-block ssh config: 40 aliases now resolve, 0
raw arrays remain, host count in listKnownHosts output is unchanged.
14 tests silently asserted POSIX-only behaviour and failed when the suite
ran on Windows: the chmod 600 config check (a no-op there), the /bin/sh
askpass helper (a .cmd batch file there), the `detached` spawn flag (POSIX
only) and a bare 'ssh' as argv[0] (an absolute ssh.exe path there).

Rather than skipping them on Windows, both platform paths are now asserted
explicitly: loadServerAs() re-imports server.mjs with process.platform
faked, so the Windows branches are covered from any host OS. SSH_BIN and
SCP_BIN are exported so tests assert against the binary the module actually
resolved instead of hardcoding a name.

Coverage of server.mjs is now 100% of statements, branches, functions and
lines, with those thresholds pinned in vitest.config.mjs so a change adding
an untested line fails the build. New tests cover the Windows PATH/PATHEXT
walk in resolveExecutable, the askpass exit/SIGINT/SIGTERM handlers, silent
mode, hostAlias type validation, the known_hosts matching fallback, glob
expansion of Include directives, output-truncation markers and the tool
dispatch timeout default.

Also drops the `|| process.env.Path` fallback in resolveExecutable: Node
exposes process.env case-insensitively on Windows, so process.env.PATH
already resolves a variable spelled `Path` and the fallback was unreachable
(and therefore uncoverable on the platform it was written for).

CI now runs the matrix on windows-latest as well as ubuntu-latest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Windows checks out CRLF by default (core.autocrlf=true). server.mjs opens
with a `#!/usr/bin/env node` shebang, and the Vite SSR transform vitest
runs the module through cannot parse that shebang with CRLF line endings:
it hoists the imports above the shebang and then fails on "Invalid
Character `!`". The whole suite aborts with a SyntaxError before a single
test runs.

This predates the multi-alias fix — a CRLF checkout of a5f9ca3 fails the
same way. It only surfaced now because CI never ran on Windows before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The MCP Server Handlers block drives the real SSHClient that main()
constructs, so every tool call spawned an actual ssh/scp process against
1.2.3.4 and blocked on the network. 'should handle runCommandBatch tool
call' issues two such commands and exceeded the 5s test timeout on Windows
CI runners, where the suite runs ~30x slower than locally.

Stub the three process-starting methods for that block: it asserts tool
dispatch and response shape, which is unaffected, and the methods
themselves are covered by the SSHClient tests. Also raises testTimeout to
15s for headroom on slow runners.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
12 high, 4 moderate, 1 low had accumulated since the 1.3.7 cleanup, and
the CI audit step blocks on high. npm audit fix is unusable here — it
aborts with an internal npm error (Cannot read properties of null reading
'edgesOut') on this tree's overrides — so the fixes are pinned explicitly.

Direct bumps, all inside the existing semver ranges: @modelcontextprotocol/sdk
1.27.1 -> 1.30.0, vitest and @vitest/coverage-v8 4.1.4 -> 4.1.10,
@anthropic-ai/dxt 0.2.5 -> 0.2.6. That cleared vite, postcss and nanoid.

Raised the existing tmp override to >=0.2.6, clearing the whole
@anthropic-ai/dxt -> @inquirer/prompts -> @inquirer/editor ->
external-editor -> tmp chain.

Added overrides for the transitive HTTP-stack advisories reachable through
the MCP SDK: brace-expansion, fast-uri, ip-address, hono, @hono/node-server,
body-parser, qs, express-rate-limit — each pinned to the lowest version
carrying the fix. As in 1.3.7 none of that code is reachable from this
package: it belongs to the SDK's HTTP/SSE transport and mcp-ssh only loads
server/stdio.js.

npm audit now reports zero vulnerabilities. Verified the suite stays green
at 100% coverage, npm ci reproduces from the lockfile, and the real server
still answers initialize and tools/list over STDIO.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ferraro
ferraro merged commit 2827d55 into AiondaDotCom:main Aug 10, 2026
6 checks passed
@ferraro

ferraro commented Aug 10, 2026

Copy link
Copy Markdown
Member

Merged — thanks for this. The root-cause analysis was spot on: ssh-config@5 returning a string for a single-token value but an array of token objects from two tokens on is exactly the kind of thing that silently breaks every strict comparison downstream, and normalizing once at parse time is the right place to fix it. The Host * !bastion and multi-token-directive defects falling out of the same normalization was a nice bonus.

I added four commits on top before merging. Summary of what changed and why:

test: make the suite platform-agnostic and pin 100% coverage

You flagged that 16 tests fail on Windows on main. I could reproduce 14 of them by faking process.platform before module load. All four causes were tests asserting POSIX-only behaviour:

Cause On Windows
checkFilePermissions No Unix mode bits → early return, so the chmod 600 assertions never fire
getAskpassScript Writes a .cmd batch file, no chmod
detached POSIX-only (makes ssh use SSH_ASKPASS instead of grabbing a tty)
SSH_BIN/SCP_BIN An absolute ssh.exe path, not 'ssh'

Rather than skipping them on Windows — which would leave coverage holes exactly where the platform-specific code lives — both paths are now asserted explicitly. A loadServerAs(platform) helper re-imports server.mjs with the platform faked (including fresh fs/promises spies, since vi.resetModules() re-runs the mock factory). SSH_BIN/SCP_BIN are exported so tests assert against the binary the module actually resolved. I also found one failure the fake doesn't surface: a mock filtering with endsWith('/config'), which misses \config on Windows.

Coverage of server.mjs is now 100% of statements, branches, functions and lines, with those thresholds pinned in vitest.config.mjs so future changes can't quietly drop below. One production line changed beyond your PR: the || process.env.Path fallback in resolveExecutable() is gone. Node exposes process.env case-insensitively on Windows, so process.env.PATH already resolves a variable spelled Path — the branch was unreachable on the very platform it was written for, and therefore uncoverable there.

ci: force LF checkout so the suite parses on Windows

This one predates your PR. Windows checks out CRLF by default (core.autocrlf=true), and Vite's SSR transform — which vitest runs the module through — can't parse the #!/usr/bin/env node shebang in server.mjs with CRLF endings: it hoists the imports above the shebang and then fails on Invalid Character `!` . The whole suite dies with a SyntaxError before a single test runs. Verified against a5f9ca3 (i.e. main before your change), which fails identically. That's almost certainly why you saw test failures rather than a parse error — you must have had autocrlf off. Fixed with a .gitattributes pinning LF.

test: stop the MCP handler tests from spawning real ssh/scp

Also pre-existing. The MCP Server Handlers block drives the real SSHClient that main() constructs, so every tool call spawned an actual ssh/scp process against 1.2.3.4 and blocked on the network. runCommandBatch issues two of them and blew the 5s timeout on Windows runners, where the suite runs roughly 30× slower than locally. The three process-starting methods are now stubbed for that block — it asserts tool dispatch and response shape, which is unaffected, and the methods themselves are covered by the SSHClient tests.

security: resolve all 17 open npm audit advisories

Unrelated to your change, but it was blocking the CI audit step. 12 high / 4 moderate / 1 low had accumulated since the 1.3.7 cleanup. npm audit fix is unusable on this tree (aborts with an internal npm error on the overrides), so the fixes are pinned explicitly: direct bumps within existing semver ranges (@modelcontextprotocol/sdk → 1.30.0, vitest → 4.1.10, @anthropic-ai/dxt → 0.2.6), the tmp override raised to >=0.2.6, and new overrides for the transitive HTTP-stack advisories under the MCP SDK. npm audit reports zero vulnerabilities again. Verified afterwards that npm ci reproduces from the lockfile and the real server still answers initialize and tools/list over STDIO.

CI now runs ubuntu-latest and windows-latest across Node 20/22/24 — all six green, 139 tests, 100% coverage enforced as a build gate.

Thanks again for the fix and for the thorough write-up in the PR description — it made this easy to review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Host blocks with multiple aliases are unreachable under any alias

2 participants